# -*- coding: utf-8 -*-
#
# Picard, the next-generation MusicBrainz tagger
#
# Copyright (C) 2023-2024 Philipp Wolfer
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.

from functools import (
    partial,
    update_wrapper,
)
from logging import (
    Logger,
    getLogger,
)
from pathlib import Path
import sys
import types


try:
    import tomllib
except ImportError:
    import tomli as tomllib  # type: ignore[no-redef,import-not-found]
from typing import (
    TYPE_CHECKING,
    Callable,
    Type,
    TypeAlias,
)


if TYPE_CHECKING:
    from picard.tagger import Tagger

from PyQt6.QtCore import QCoreApplication

from picard.album import Album
from picard.album_requests import TaskType
from picard.cluster import Cluster
from picard.config import (
    Config,
    ConfigSection,
    get_config,
)
from picard.coverart.image import CoverArtImage
from picard.coverart.providers import CoverArtProvider
from picard.extension_points.cover_art_filters import (
    register_cover_art_filter,
    register_cover_art_metadata_filter,
)
from picard.extension_points.cover_art_processors import (
    ImageProcessor,
    register_cover_art_processor,
)
from picard.extension_points.cover_art_providers import (
    register_cover_art_provider,
)
from picard.extension_points.event_hooks import (
    register_album_post_removal_processor,
    register_file_post_addition_to_track_processor,
    register_file_post_load_processor,
    register_file_post_removal_from_track_processor,
    register_file_post_save_processor,
)
from picard.extension_points.formats import register_format
from picard.extension_points.item_actions import (
    BaseAction,
    register_album_action,
    register_cluster_action,
    register_clusterlist_action,
    register_file_action,
    register_track_action,
)
from picard.extension_points.metadata import (
    register_album_metadata_processor,
    register_track_metadata_processor,
)
from picard.extension_points.options_pages import register_options_page
from picard.extension_points.script_functions import register_script_function
from picard.extension_points.script_variables import register_script_variable
from picard.extension_points.ui_init import register_ui_init
from picard.file import File
from picard.metadata import Metadata
from picard.plugin3.i18n import (
    PluginTranslator,
    get_plural_form,
)
from picard.plugin3.manifest import PluginManifest
from picard.track import Track
from picard.webservice import (
    PendingRequest,
    WebService,
)
from picard.webservice.api_helpers import MBAPIHelper

from picard.ui.options import OptionsPage


def t_(key, text=None, plural=None):
    """Mark a string for translation extraction (no-op at runtime).

    This is a marker function for the translation extractor. At runtime,
    it returns the arguments in a format suitable for tr() or trn().

    Args:
        key: Translation key
        text: Default text (singular form)
        plural: Plural form (optional)

    Returns:
        - If no plural: returns key
        - If plural: returns (key, text, plural) tuple

    Example:
        # Simple translation
        ERROR_MSG = t_('error.not_found', 'Not found')
        # Use: api.tr(ERROR_MSG, 'Not found')

        # Plural translation
        FILE_COUNT = t_('files.count', '{n} file', '{n} files')
        # Use: api.trn(*FILE_COUNT, n=count)
    """
    if plural is not None:
        return (key, text, plural)
    return key


# Classes that plugins can import directly for inheritance
__all__ = [
    'PluginApi',
    't_',
]


class PluginApi:
    # Class references for plugins to use
    Album: TypeAlias = Album
    Track: TypeAlias = Track
    File: TypeAlias = File
    Cluster: TypeAlias = Cluster
    Metadata: TypeAlias = Metadata
    CoverArtImage: TypeAlias = CoverArtImage
    CoverArtProvider: TypeAlias = CoverArtProvider
    BaseAction: TypeAlias = BaseAction
    OptionsPage: TypeAlias = OptionsPage

    # Class-level registries for get_api()
    _instances: dict[str, 'PluginApi'] = {}  # Maps module name -> PluginApi instance
    _module_cache: dict[str, 'PluginApi'] = {}  # Maps module name -> PluginApi instance (for faster lookup)
    _deprecation_warnings_emitted: set[tuple[str, str, int]] = set()  # Track emitted deprecation warnings

    def __init__(self, manifest: PluginManifest, tagger: 'Tagger') -> None:
        self._tagger: 'Tagger' = tagger
        self._manifest = manifest
        self._plugin_module: types.ModuleType | None = None  # Will be set when plugin is enabled
        full_name = f'plugin.{self._manifest.uuid}'
        self._logger = getLogger(f'main.plugin.{self._manifest.module_name}')
        self._api_config = ConfigSection(get_config(), full_name)
        self._translations: dict[str, dict] = {}
        self._source_locale = manifest.source_locale
        self._plugin_dir: Path | None = None
        self._qt_translator: PluginTranslator | None = None

    @staticmethod
    def get_caller_info(frame_depth=2):
        """Get caller information for deprecation warnings.

        Args:
            frame_depth: Number of frames to go back (default 2)

        Returns:
            Tuple of (plugin_name, filename, lineno)
        """
        import sys

        frame = sys._getframe(frame_depth)
        filename = frame.f_code.co_filename
        lineno = frame.f_lineno

        plugin_name = "unknown"
        if 'plugins3' in filename:
            parts = filename.split('/')
            try:
                idx = parts.index('plugins3')
                plugin_name = parts[idx + 1]
                # Truncate to show only relative path within plugin directory
                filename = '/'.join(parts[idx + 2 :])
            except (ValueError, IndexError):
                pass

        return plugin_name, filename, lineno

    @classmethod
    def deprecation_warning(cls, message, *args, frame_depth=3):
        """Emit a deprecation warning once per unique caller location.

        Args:
            message: Warning message format string
            *args: Arguments for message formatting
            frame_depth: Number of frames to go back (default 3)
        """
        from picard import log

        plugin_name, filename, lineno = cls.get_caller_info(frame_depth=frame_depth)
        warning_key = (plugin_name, filename, lineno)
        if warning_key not in cls._deprecation_warnings_emitted:
            cls._deprecation_warnings_emitted.add(warning_key)
            log.warning(
                "Plugin '%s' at %s:%d: " + message,
                plugin_name,
                filename,
                lineno,
                *args,
            )

    def _install_qt_translator(self) -> None:
        """Install Qt translator for .ui file translations."""
        if not self._translations:
            return

        self._qt_translator = PluginTranslator(self._translations, self._source_locale)
        self._qt_translator._current_locale = self.get_locale()
        QCoreApplication.installTranslator(self._qt_translator)

    def _remove_qt_translator(self) -> None:
        """Remove Qt translator for .ui file translations."""
        if not self._qt_translator:
            return

        QCoreApplication.removeTranslator(self._qt_translator)
        self._qt_translator = None

    def _is_valid_locale(self, locale: str) -> bool:
        """Check if locale string is valid (basic sanity check).

        Valid locales are 2-5 characters, alphanumeric with optional underscore.
        Examples: en, de, pt_BR, zh_CN
        Case-insensitive to work on case-insensitive filesystems.
        """
        import re

        return bool(re.match(r'^[a-z]{2,3}(_[a-z]{2})?$', locale, re.IGNORECASE))

    def _find_translation_file(self, locale: str) -> tuple[Path | None, str | None]:
        """Find translation file for locale, preferring TOML over JSON.

        Returns:
            (file_path, format) or (None, None) if not found
        """
        if not self._plugin_dir:
            return None, None

        locale_dir = self._plugin_dir / 'locale'
        if not locale_dir.exists():
            return None, None

        # Check TOML first, then JSON
        toml_file = locale_dir / f'{locale}.toml'
        if toml_file.exists():
            return toml_file, 'toml'

        json_file = locale_dir / f'{locale}.json'
        if json_file.exists():
            return json_file, 'json'

        return None, None

    def _load_translation_file(self, file_path: Path, format: str, locale: str) -> dict | None:
        """Load a single translation file."""
        try:
            if format == 'toml':
                with open(file_path, 'rb') as f:
                    data = tomllib.load(f)
                    self._check_toml_structure(data, locale)
                    self._logger.debug(f"Loaded {format.upper()} translation file: {file_path}")
                    return data
            elif format == 'json':
                import json

                with open(file_path, encoding='utf-8') as f:
                    data = json.load(f)
                    self._logger.debug(f"Loaded {format.upper()} translation file: {file_path}")
                    return data
        except Exception as e:
            self._logger.warning(f"Failed to load translation file {file_path}: {e}")
        return None

    def _check_toml_structure(self, data: dict, locale: str) -> None:
        """Check for nested structure in TOML and warn about unquoted keys."""

        def find_nested_keys(d: dict, path: str = '') -> list:
            nested = []
            for k, v in d.items():
                current_path = f'{path}.{k}' if path else k
                if isinstance(v, dict) and not self._is_plural_dict(v):
                    # Found nested dict - this means unquoted keys were used
                    nested.append(current_path)
                    # Recursively check deeper nesting
                    nested.extend(find_nested_keys(v, current_path))
            return nested

        nested_keys = find_nested_keys(data)
        if nested_keys:
            self._logger.warning(
                f"Translation file for locale '{locale}' uses nested structure. "
                f"Keys with dots should be quoted in TOML. Found nested keys: {', '.join(nested_keys[:3])}"
                f"{' and more...' if len(nested_keys) > 3 else ''}. "
                f"Example: use [\"message.test\"] instead of [message.test]"
            )

    def _is_plural_dict(self, d: dict) -> bool:
        """Check if dict is a plural forms dict (has keys like 'one', 'other', etc.)."""
        plural_keys = {'zero', 'one', 'two', 'few', 'many', 'other'}
        return bool(d.keys() & plural_keys)

    def _load_translations(self) -> None:
        """Load translation files from locale/ directory.

        Only loads translations for the current locale to avoid unnecessary I/O.
        """
        if not self._plugin_dir:
            return

        locale_dir = self._plugin_dir / 'locale'
        if not locale_dir.exists():
            return

        # Get current locale
        current_locale = self.get_locale()

        # Try to load current locale
        file_path, format = self._find_translation_file(current_locale)
        if file_path:
            assert format is not None
            data = self._load_translation_file(file_path, format, current_locale)
            if data:
                self._translations[current_locale] = data
                # Log if this is the source locale
                if current_locale == self._source_locale or current_locale.split('_')[0] == self._source_locale:
                    self._logger.debug(f"Loaded source locale ({self._source_locale}) translation file")
                return

        # Fallback: try language without region (e.g., 'de' from 'de_DE')
        if '_' in current_locale:
            lang = current_locale.split('_')[0]
            file_path, format = self._find_translation_file(lang)
            if file_path:
                assert format is not None
                data = self._load_translation_file(file_path, format, lang)
                if data:
                    self._translations[lang] = data
                    # Log if this is the source locale
                    if lang == self._source_locale:
                        self._logger.debug(f"Loaded source locale ({self._source_locale}) translation file")

    @property
    def tagger(self):
        """Access to the main Tagger instance."""
        return self._tagger

    @property
    def web_service(self) -> WebService:
        return self._tagger.webservice

    @property
    def mb_api(self) -> MBAPIHelper:
        return MBAPIHelper(self._tagger.webservice)

    @property
    def logger(self) -> Logger:
        return self._logger

    @property
    def plugin_id(self) -> str:
        """Plugin identifier (module name)."""
        return self._manifest.module_name

    @property
    def global_config(self) -> Config:
        return get_config()

    @property
    def plugin_config(self) -> ConfigSection:
        """Configuration private to the plugin"""
        return self._api_config

    @property
    def plugin_dir(self) -> Path | None:
        """Path to the plugin directory.

        Returns:
            Path: Plugin directory path, or None if not available
        """
        return self._plugin_dir

    def get_locale(self) -> str:
        """Get the current locale used by Picard.

        Returns:
            str: Current locale code (e.g., 'en', 'de_DE', 'pt_BR')
        """
        from PyQt6.QtCore import QLocale

        # Use Picard's UI language setting if available
        config = get_config()
        ui_language = config.setting['ui_language']
        if ui_language:
            return ui_language

        # Fall back to system locale
        return QLocale().name()

    @classmethod
    def get_api(cls) -> 'PluginApi':
        """Get the PluginApi instance for the calling plugin module.

        This is a convenience method for accessing the API instance from
        anywhere in plugin code without explicitly passing it around.

        Returns:
            PluginApi: The API instance for the calling plugin

        Raises:
            RuntimeError: If called from outside a plugin context

        Example:
            class MyWidget(QWidget):
                def __init__(self):
                    super().__init__()
                    api = PluginApi.get_api()
                    api.logger.info("Widget initialized")
        """
        frame = sys._getframe(1)
        module_name = frame.f_globals.get('__name__')
        if module_name is None:
            raise RuntimeError(f"No module_name found in {frame}")

        # Check cache first
        if module_name in cls._module_cache:
            return cls._module_cache[module_name]

        # Cache miss - do the lookup
        # Try exact match first
        if module_name in cls._instances:
            api = cls._instances[module_name]
        else:
            # Try to find parent module (for submodules)
            api = None
            for registered_module in cls._instances:
                if module_name.startswith(registered_module + '.'):
                    api = cls._instances[registered_module]
                    break

            if api is None:
                raise RuntimeError(f"No PluginApi instance found for module {module_name}")

        # Cache the result
        assert api is not None
        cls._module_cache[module_name] = api
        return api

    # Translation
    def tr(self, key: str, text: str | None = None, **kwargs) -> str:
        """Translate a string for the plugin.

        Args:
            key: Translation key
            text: Default text (fallback if no translation found)
            **kwargs: Placeholder values for string formatting

        Returns:
            Translated string with placeholders substituted
        """
        result = None

        # Try to get translation from loaded files
        if self._translations:
            locale = self.get_locale()
            # Try exact locale match (e.g., de_DE)
            if locale in self._translations and key in self._translations[locale]:
                result = self._translations[locale][key]
            else:
                # Try language without region (e.g., de from de_DE)
                lang = locale.split('_')[0]
                if lang in self._translations and key in self._translations[lang]:
                    result = self._translations[lang][key]

        # Fall back to text parameter or key
        if result is None:
            result = text if text is not None else key

        # Apply placeholder substitution
        if kwargs:
            result = result.format(**kwargs)

        return result

    def trn(self, key: str, singular: str | None = None, plural: str | None = None, n: int = 0, **kwargs) -> str:
        """Translate a string with plural forms.

        Args:
            key: Translation key
            singular: Default singular text (for n=1 in English)
            plural: Default plural text (for n!=1 in English)
            n: Number to determine plural form
            **kwargs: Placeholder values for string formatting

        Returns:
            Translated string with placeholders substituted
        """
        # Ensure n is in kwargs for formatting
        if 'n' not in kwargs:
            kwargs['n'] = n

        result = None

        # Try to get translation from loaded files
        if self._translations:
            locale = self.get_locale()
            plural_form = get_plural_form(locale, n)

            # Try exact locale match
            if locale in self._translations and key in self._translations[locale]:
                trans = self._translations[locale][key]
                if isinstance(trans, dict) and plural_form in trans:
                    result = trans[plural_form]
                elif isinstance(trans, dict) and 'other' in trans:
                    result = trans['other']
            else:
                # Try language without region
                lang = locale.split('_')[0]
                if lang in self._translations and key in self._translations[lang]:
                    trans = self._translations[lang][key]
                    if isinstance(trans, dict) and plural_form in trans:
                        result = trans[plural_form]
                    elif isinstance(trans, dict) and 'other' in trans:
                        result = trans['other']

        # Fall back to singular/plural parameters
        if result is None:
            if n == 1 and singular is not None:
                result = singular
            elif plural is not None:
                result = plural
            elif singular is not None:
                result = singular
            else:
                result = key

        # Apply placeholder substitution
        if kwargs:
            result = result.format(**kwargs)

        return result

    # Metadata processors
    def register_album_metadata_processor(self, function: Callable, priority: int = 0) -> None:
        wrapped = partial(function, self)
        update_wrapper(wrapped, function)
        return register_album_metadata_processor(wrapped, priority)

    def register_track_metadata_processor(self, function: Callable, priority: int = 0) -> None:
        wrapped = partial(function, self)
        update_wrapper(wrapped, function)
        return register_track_metadata_processor(wrapped, priority)

    # Event hooks
    def register_album_post_removal_processor(self, function: Callable, priority: int = 0) -> None:
        wrapped = partial(function, self)
        update_wrapper(wrapped, function)
        return register_album_post_removal_processor(wrapped, priority)

    def register_file_post_load_processor(self, function: Callable, priority: int = 0) -> None:
        wrapped = partial(function, self)
        update_wrapper(wrapped, function)
        return register_file_post_load_processor(wrapped, priority)

    def register_file_post_addition_to_track_processor(self, function: Callable, priority: int = 0) -> None:
        wrapped = partial(function, self)
        update_wrapper(wrapped, function)
        return register_file_post_addition_to_track_processor(wrapped, priority)

    def register_file_post_removal_from_track_processor(self, function: Callable, priority: int = 0) -> None:
        wrapped = partial(function, self)
        update_wrapper(wrapped, function)
        return register_file_post_removal_from_track_processor(wrapped, priority)

    def register_file_post_save_processor(self, function: Callable, priority: int = 0) -> None:
        wrapped = partial(function, self)
        update_wrapper(wrapped, function)
        return register_file_post_save_processor(wrapped, priority)

    # Cover art
    def register_cover_art_provider(self, provider: Type[CoverArtProvider]) -> None:
        return register_cover_art_provider(provider)

    def register_cover_art_filter(self, filter: Callable) -> None:
        wrapped = partial(filter, self)
        update_wrapper(wrapped, filter)
        return register_cover_art_filter(wrapped)

    def register_cover_art_metadata_filter(self, filter: Callable) -> None:
        wrapped = partial(filter, self)
        update_wrapper(wrapped, filter)
        return register_cover_art_metadata_filter(wrapped)

    def register_cover_art_processor(self, processor_class: Type[ImageProcessor]) -> None:
        return register_cover_art_processor(processor_class)

    # File formats
    def register_format(self, format: Type[File]) -> None:
        return register_format(format)

    # Scripting
    def register_script_function(
        self,
        function: Callable,
        name: str | None = None,
        eval_args: bool = True,
        check_argcount: bool = True,
        documentation: str | None = None,
    ) -> None:
        return register_script_function(function, name, eval_args, check_argcount, documentation)

    def register_script_variable(self, name: str, documentation: str | None = None) -> None:
        return register_script_variable(name, documentation)

    # Context menu actions
    def register_album_action(self, action: BaseAction) -> None:
        return register_album_action(action, self)

    def register_cluster_action(self, action: BaseAction) -> None:
        return register_cluster_action(action, self)

    def register_clusterlist_action(self, action: BaseAction) -> None:
        return register_clusterlist_action(action, self)

    def register_track_action(self, action: BaseAction) -> None:
        return register_track_action(action, self)

    def register_file_action(self, action: BaseAction) -> None:
        return register_file_action(action, self)

    # UI
    def register_options_page(self, page_class: Type[OptionsPage]) -> None:
        return register_options_page(page_class, self)

    def register_ui_init(self, function: Callable) -> None:
        wrapped = partial(function, self)
        update_wrapper(wrapped, function)
        return register_ui_init(wrapped)

    # Album task management for plugins
    def add_album_task(
        self,
        album: Album,
        task_id: str,
        description: str,
        timeout: float | None = None,
        request_factory: Callable[[], PendingRequest] | None = None,
    ) -> None:
        """Add a plugin task to an album.

        Plugin tasks are always non-blocking (TaskType.PLUGIN) and will not
        prevent the album from being marked as loaded. This allows plugins to fetch
        additional data asynchronously without blocking the user interface.

        Args:
            album: The Album object to add the task to
            task_id: Unique identifier for this task (will be prefixed with plugin_id)
            description: Human-readable description of what the task does
            timeout: Optional timeout in seconds
            request_factory: Optional callable that creates and returns a PendingRequest.
                           If provided, the request is created and registered atomically.

        Example:
            def fetch_extra_data(api, album, metadata, release):
                task_id = f'extra_data_{album.id}'
                api.add_album_task(
                    album, task_id, 'Fetching artist biography',
                    request_factory=lambda: api.web_service.get_url(
                        url=f'https://example.com/artist/{artist_id}',
                        handler=lambda data, http, error: api.complete_album_task(album, task_id)
                    )
                )
        """
        full_task_id = f'{self.plugin_id}_{task_id}'
        album.add_task(
            full_task_id,
            TaskType.PLUGIN,
            f'[{self.plugin_id}] {description}',
            timeout=timeout,
            plugin_id=self.plugin_id,
            request_factory=request_factory,
        )

    def complete_album_task(self, album: Album, task_id: str) -> None:
        """Mark a plugin task as complete.

        Args:
            album: The Album object the task was added to
            task_id: The same task_id used in add_album_task (without plugin prefix)

        Example:
            api.complete_album_task(album, 'extra_data')
        """
        full_task_id = f'{self.plugin_id}_{task_id}'
        album.complete_task(full_task_id)

    # Other ideas
    # Implement status indicators as an extension point. This allows plugins
    # that use alternative progress displays
    # def register_status_indicator(self, function: Callable) -> None:
    #     pass

    # Register page for file properties. Same for track and album
    # def register_file_info_page(self, page_class):
    #     pass
